Skip to content

feat(cron): a scheduled job can fire from a calendar or a file change - #260

Merged
oratis merged 1 commit into
mainfrom
feat/cron-trigger-sources
Aug 9, 2026
Merged

feat(cron): a scheduled job can fire from a calendar or a file change#260
oratis merged 1 commit into
mainfrom
feat/cron-trigger-sources

Conversation

@oratis

@oratis oratis commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Stacked on #252.

Closes the 未做 item in docs/FLOATBOAT_ADOPTION_PLAN.md §4.1: "触发源抽象(ICS / watch)… 未做。cron 仍只有时间源".

The gap

Until now the only trigger was a clock. "Run the release checklist when the release meeting starts" and "regenerate the client when the schema changes" are both scheduling, and neither is a time.

{ "id": "release-prep", "trigger": { "kind": "ics", "path": "team.ics", "match": "release" },  }
{ "id": "regen",       "trigger": { "kind": "file", "paths": ["schema.json"] },  }

schedule still means cron and still works. No existing job is migrated — a store nobody has to rewrite cannot be rewritten wrongly.

Everything is polled

scheduler run already wakes on a timer and asks what is due; every source answers that same question. No daemon, no watcher process, no way for a trigger to fire while nothing is listening.

The cost is minute granularity everywhere — which is the granularity anyone can observe anyway, since a second-resolution trigger would fire or not depending on how promptly launchd got around to it.

ICS: standard text, nothing else

No vendor SDK, no OAuth to a calendar service, no remote account polling. Every calendar worth integrating with exports .ics, and a file on disk is a boundary you can inspect — which a client library is not.

Construct Behaviour
DTSTART UTC / floating / all-day Used / read as UTC / never fires
Folded SUMMARY lines Unfolded — calendars fold at 75 octets, so this is ordinary input, not exotic
RRULE FREQ=DAILY/WEEKLY + INTERVAL/BYDAY/UNTIL/COUNT Expanded
Anything else Reported, never dropped

That last row is the important one, and it follows the file-contract parser's rule: a silently ignored RRULE is a job that never fires, and that failure is indistinguishable from "nothing was scheduled". FREQ=MONTHLY, BYDAY=2MO, BYMONTHDAY, TZID — each is logged next to the job that hit it.

TZID is reported rather than honoured: there is no timezone database here, and applying the host's zone would make one file fire at different moments on different machines.

All-day entries never fire. They name a day, not a moment, and picking one (midnight? 09:00?) would be inventing a schedule the user did not write.

File triggers have two deliberate behaviours

  • The first evaluation records a baseline instead of firing — otherwise every file trigger goes off the moment it is created, on files nobody has touched since anyone cared. The scheduler stamps that baseline itself; without it the job could never acquire one and would stay silent forever while looking configured.
  • A missing path is not a change. A watched file may not have been generated yet, and reporting it every minute would bury the messages that matter.

Permissions are untouched

A trigger decides when, never what may happen. Scheduled runs still go through the unattended clamp from #244. A calendar you do not control deciding when DeepCode runs is already worth thinking about; it must not also decide what it may do.

Verification

typecheck, lint, format, docs clean; full suite green. 48 new tests, weighted at the recurrence arithmetic, which is where this is most likely to be subtly wrong: DAILY/WEEKLY interval, BYDAY, UNTIL, COUNT (counting the first occurrence, and counting per-BYDAY within a week), time-of-day preservation, and not matching before the first occurrence.

🤖 Generated with Claude Code

@oratis

oratis commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Review — approve, no changes

The framing carries this: "run the release checklist when the release meeting starts" and "regenerate when the schema changes" are both scheduling, and neither is a time. Extending the trigger rather than bolting a watcher onto the side is the right shape.

The decisions I'd have argued about are all made the way I'd want:

  • Everything is polled. scheduler run already wakes and asks what is due; every source answers the same question. No daemon, no watcher process, and no way for a trigger to fire while nothing is listening. Minute granularity is the honest ceiling anyway — a second-resolution trigger would fire or not depending on how promptly launchd got around to it.
  • schedule still means cron and no job is migrated. resolveTrigger falls through to the legacy string. A store nobody has to rewrite cannot be rewritten wrongly.
  • Unsupported RRULE constructs are reported, never dropped. This is the one that would have hurt: a silently ignored FREQ=MONTHLY is a job that never fires, and that is indistinguishable from nothing being scheduled. Same rule the file-contract parser follows.
  • TZID reported rather than honoured. Applying the host's zone would make one file fire at different moments on different machines. Reporting is the only answer that does not invent something.
  • All-day entries never fire. They name a day, not a moment; picking midnight or 09:00 would be inventing a schedule the user did not write.
  • A file trigger with no lastRunAt records a baseline instead of firing. Otherwise every file trigger fires once the moment it is created, on files nobody touched — which is the kind of thing that teaches people to distrust the feature immediately.
  • A missing watched path is not a change. Reporting it every minute would bury the message that matters.

occursAt does its time-of-day and weekday arithmetic in UTC throughout, consistent with the "no timezone database here" stance. An unreadable calendar is reported rather than silently returning no events — right, because those are different problems.

Choosing .ics on disk over a calendar SDK is worth calling out as the correct trade: a file is a boundary you can inspect, and it needs no OAuth to anything.

@oratis
oratis changed the base branch from fix/git-env-test-isolation to main August 9, 2026 15:38
@oratis
oratis force-pushed the feat/cron-trigger-sources branch from 5445c1e to 81af00c Compare August 9, 2026 16:05
Until now the only trigger was a clock. "Run the release checklist when the
release meeting starts" and "regenerate the client when the schema changes" are
both scheduling, and neither is a time.

`TriggerSource` is cron | ics | file. `schedule` still means cron and still
works, so no existing job needs migrating — a store nobody has to rewrite cannot
be rewritten wrongly.

Every source is polled by the `scheduler run` that already exists. No daemon, no
watcher process, no way for a trigger to fire while nothing is listening. The
cost is minute granularity everywhere, which is the granularity anyone can
actually observe: a second-resolution trigger would fire or not depending on how
promptly launchd got around to it.

ICS is standard calendar text and nothing else — no vendor SDK, no OAuth to a
calendar service, no remote account. Every calendar worth integrating with
exports `.ics`, and a file on disk is a boundary you can inspect, which a client
library is not.

The reader handles DTSTART (UTC, floating, all-day), folded SUMMARY lines, and
RRULE FREQ=DAILY/WEEKLY with INTERVAL/BYDAY/UNTIL/COUNT. Everything else is
*reported*, following the file-contract parser's rule: a silently ignored RRULE
is a job that never fires, and that failure is indistinguishable from "nothing
was scheduled". TZID is reported rather than honoured — there is no timezone
database here, and applying the host's zone would make one file fire at
different moments on different machines.

All-day entries never fire. They name a day, not a moment, and picking one would
be this module inventing a schedule the user did not write.

A file trigger's first evaluation records a baseline instead of firing, or every
one of them would go off the moment it was created on files nobody had touched.
The scheduler stamps that baseline itself — without it the job could never
acquire one and would stay silent forever while looking configured.

A trigger decides when, never what may happen: scheduled runs still go through
the unattended clamp. A calendar you do not control deciding when DeepCode runs
is already worth thinking about; it must not also decide what it may do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis force-pushed the feat/cron-trigger-sources branch from 81af00c to 027afa8 Compare August 9, 2026 16:10
@oratis

oratis commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Note from the rebase onto main

Resolving the docs/FLOATBOAT_ADOPTION_PLAN.md §4.1 conflict turned up a third row that was also stale: Grep/Glob 结果过滤 still read 仍未做, even though #254 closed it and its PR description said so. It shipped without updating the table it cited.

All three rows in that table are now 已做 — Grep/Glob filtering (#254), 制品 provenance (#261), and trigger sources (this PR) — so the plan matches main rather than trailing it by three PRs.

@oratis
oratis merged commit cd22748 into main Aug 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant